Skip to content

feat(sdks/go): stream reconnect and lifecycle for listeners - #4257

Merged
igor-kupczynski merged 3 commits into
mainfrom
stream-reconnect-lifecycle
Jul 9, 2026
Merged

feat(sdks/go): stream reconnect and lifecycle for listeners#4257
igor-kupczynski merged 3 commits into
mainfrom
stream-reconnect-lifecycle

Conversation

@igor-kupczynski

@igor-kupczynski igor-kupczynski commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Description

Stream/listener half of #4228, building on the retry primitives from #4240 (merged).

The legacy Go SDK (pkg/client) keeps four long-lived gRPC streams: workflow-run subscriptions, durable event subscriptions, the worker action stream, and the metadata event stream. Each had its own reconnect logic layered on top of interceptor retry, with different behaviors, e.g.

  • Workflow.Result() could hang forever if the shared listener died
  • the worker action listener gave up after 5 transient Recv failures (which could killed the worker on a flaky network)
  • a caller cancelling its context could tear down a listener shared with other callers

This PR replaces that with one explicit app-level reconnect model (full-jitter backoff, 1s base / 30s cap) shared by every stream.

Behavior changes

  • Sync paths (initial subscribe, AddWorkflowRun, AddSignal, sends): bounded reconnect — 5 attempts, then fail.
  • Background listen loops: reconnect indefinitely on transient errors; permanent errors and 10 consecutive no-progress failures stop the loop and surface to callers.
  • Workflow.Result() fails fast when subscribing fails, and waiters are unblocked if the listener dies permanently.
  • The worker action listener reconnects until closed instead of dying after 5 transient failures (V2→V1 Unimplemented fallback preserved).
  • Reconnects run on the stream's lifecycle context, so caller ctx cancellation never kills a shared listener. Interceptor stream retry is disabled on the RPCs the app-level loop now owns.

Review guide

Four shared components are the main part to look at and then listeners are thin wrappers over them:

  1. stream_classify.go: four-verdict error classifier used by both recv and reconnect paths.
  2. reconnecting_stream.go -->reconnectingStream[C]: singleflighted connects, generation counter for stale clients, bounded sync connect, lifecycle ctx owned by Close().
  3. stream_listen.go: the single flat listen loop (backoff sleeps, no-progress cap).
  4. handler_registry.go --> handlerRegistry[K,E]: RWMutex over plain maps, registration-id-guarded removal, failAll on permanent death.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Documentation change (pure documentation change)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (non-breaking changes to code which doesn't change any behaviour)
  • CI (any automation pipeline changes)
  • Chore (changes which are not directly related to any business logic)
  • Test changes (add, refactor, improve or change a test)
  • This change requires a documentation update

Checklist

Changes have been:

  • Tested (unit, integration, or manually with steps specified)
  • Linted and formatted
  • Documented (where applicable)
  • Added to CHANGELOG (where applicable) -- see Keep a Changelog

Testing

  • go build ./... && go test ./pkg/client/... ./pkg/worker/... -count=1 -race
  • Unit tests per shared component: classifier, reconnecting stream (coalescing, generations, bounded sync connect), listen loop (no-progress cap, unbounded transient reconnect, EOF policy), handler registry (stale removal, failAll, races).
  • Per-listener behavior tests: bounded sync subscribe/send, worker V2→V1 fallback and survival past 5 transient failures, Workflow.Result() fail-fast and permanent-death notification, handler-error isolation.

🤖 AI Disclosure
  • I acknowledge that an LLM was used in the creation of this Pull Request, in accordance with Hatchet's AI_POLICY.md.

  • Details: Cursor (Claude) used for implementation, test coverage, review follow-ups, and PR description drafting.

@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hatchet-docs Ready Ready Preview, Comment Jul 9, 2026 10:38am

Request Review

@github-actions github-actions Bot added the engine Related to the core Hatchet engine label Jun 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Benchmark results

goos: linux
goarch: amd64
pkg: github.com/hatchet-dev/hatchet/pkg/scheduling/v1
cpu: AMD Ryzen 9 7950X3D 16-Core Processor          
              │ /tmp/old.txt │         /tmp/new.txt          │
              │    sec/op    │    sec/op     vs base         │
RateLimiter-8   50.15µ ± 11%   53.71µ ± 42%  ~ (p=0.180 n=6)

              │ /tmp/old.txt │         /tmp/new.txt          │
              │     B/op     │     B/op      vs base         │
RateLimiter-8   137.7Ki ± 0%   137.7Ki ± 0%  ~ (p=0.506 n=6)

              │ /tmp/old.txt │          /tmp/new.txt          │
              │  allocs/op   │  allocs/op   vs base           │
RateLimiter-8    1.022k ± 0%   1.022k ± 0%  ~ (p=1.000 n=6) ¹
¹ all samples are equal

pkg: github.com/hatchet-dev/hatchet/pkg/scheduling/v1/concurrency
                                        │ /tmp/new.txt │
                                        │    sec/op    │
ConcurrencyThroughput/wal/batch=100-8      2.317 ±  1%
ConcurrencyThroughput/wal/batch=1000-8    288.5m ±  3%
ConcurrencyThroughput/wal/batch=10000-8   54.89m ±  5%
ConcurrencyThroughput/initial-drain-8     10.18m ± 14%
geomean                                   139.0m

                                        │ /tmp/new.txt │
                                        │     B/op     │
ConcurrencyThroughput/wal/batch=100-8     124.2Mi ± 0%
ConcurrencyThroughput/wal/batch=1000-8    119.8Mi ± 0%
ConcurrencyThroughput/wal/batch=10000-8   109.2Mi ± 0%
ConcurrencyThroughput/initial-drain-8     13.49Mi ± 0%
geomean                                   68.43Mi

                                        │ /tmp/new.txt │
                                        │  allocs/op   │
ConcurrencyThroughput/wal/batch=100-8      780.5k ± 0%
ConcurrencyThroughput/wal/batch=1000-8     313.3k ± 0%
ConcurrencyThroughput/wal/batch=10000-8    97.75k ± 0%
ConcurrencyThroughput/initial-drain-8      49.06k ± 0%
geomean                                    185.0k

                                        │ /tmp/new.txt │
                                        │  slots/sec   │
ConcurrencyThroughput/wal/batch=100-8     86.33k ±  1%
ConcurrencyThroughput/wal/batch=1000-8    693.2k ±  3%
ConcurrencyThroughput/wal/batch=10000-8   3.644M ±  5%
ConcurrencyThroughput/initial-drain-8     19.64M ± 17%
geomean                                   1.439M

Compared against main (6b176e1)

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

Rebuild stream listener lifecycle handling around shared reconnect
components so action, durable, and workflow streams recover consistently
without orphaned handlers or tight retry loops. Handler failures are
logged and isolated instead of being treated as shared stream death, so
unrelated waiters remain registered while true listener failures still
surface through failAll.
// ClassifyStreamError maps a stream error to a reconnect decision.
func ClassifyStreamError(ctx context.Context, err error) StreamDecision {
if err == nil {
return StreamDecisionStop

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is defensive, err should be non-nill; add a comment

@igor-kupczynski
igor-kupczynski marked this pull request as ready for review July 7, 2026 08:27
@igor-kupczynski
igor-kupczynski requested review from abelanger5 and mnafees and removed request for mnafees July 7, 2026 11:11
Comment thread pkg/client/reconnecting_stream.go Outdated
continue
}

if rerr := s.connectSync(ctx); rerr != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we honour some sort of deadline on the context so that this is not blocked for too long? The reason for the question is that I see we try StreamSyncMaxAttempts here and then StreamSyncMaxAttempts in connectSync as well which seems to stack up to a minute or so's delay unless that is going to expected behavior?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. You are correct that the send path and the reconnect path each being "bounded at 5" compounds (up to ~25 connect attempts and roughly a minute-plus of backoff worst case). BTW this mirrors what the legacy retrySend → doRetrySubscribe on main already did, but that's not a reason to keep it.

I will flatten retrySend to make exactly one connectOnce attempt per failed send, with a single backoff per attempt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread pkg/client/listener.go
if !w.startListening() {
if w.isClosed() {
return errListenerClosed
remove := l.reg.store(workflowRunId, sessionId, handler, onError)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We store the handler before calling retrySend so if the background loop happens to reconnect in that gap, replayHandlers will already send a subscribe for this key, and then retrySend sends it again. We need to make sure for the same run ID the subscribe is idempotent on the server side it seems.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, so I think we are fine. Here's my thinking:

  • Duplicate subscribes for the same run ID on a stream are idempotent because subscribeToWorkflowRunsV1 funnels every subscribe into workflowRunAcks.addWorkflowRun, which is a map write (acks[id] <- false), and sendEvent only sends while the entry exists, deleting it after.
  • the store->replay->retrySend race produces an extra wire message

One caveat: delivery is at-least-once: a re-subscribe after the completion was already acked re-delivers the finished event via the poller. That behavior predates this PR, and afaict it's on purpose as handlers are duplicate-tolerant (Result() takes the first value off a buffered channel).

Comment thread pkg/client/listener.go
}

if err := ctx.Err(); err != nil {
if err := l.ensureListening(lifecycle); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Calling this after a successful send is basically always a no-op because the gate is already active, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, so after a successful send this is indeed a no-op.

It's there for the loop dying between the first ensureListening and the send completing, as without it, the handler would be registered and subscribed on the wire, but nothing would be receiving.

There's the TestWorkflowRunsListenerRestartsAfterListenExits to test for this behavior.

case codes.Unauthenticated, codes.PermissionDenied, codes.InvalidArgument,
codes.FailedPrecondition, codes.NotFound, codes.Unimplemented:
return StreamDecisionStop
case codes.Unavailable, codes.Internal, codes.DeadlineExceeded, codes.ResourceExhausted:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick question on putting DeadlineExceeded in the retry bucket. On the sync paths the ctx.Err() checks catch a real client-side deadline, but if the server returns a DeadlineExceeded status while our context is still live, we'll keep reconnecting on it. Is that intended, or should it count as no-progress instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended, here's my thinking:

  • The classifier checks ctx.Err() first, so a real client-side deadline stops the loop.
  • What lands in the retry bucket is only a DEADLINE_EXCEEDED status sent by the server and my working assumption is that on a long-lived stream that usually means a network blip cut an otherwise healthy connection --> so that's a transient failure we want to reconnect through.
  • OTOH if we counted it as no-progress, a worker would "die" as a result.

WDYT?

retrySend ran a full 5-attempt connectSync round per failed send,
compounding to up to 25 connect attempts and ~2 minutes of backoff on
the AddWorkflowRun/AddSignal sync path. Each failed send now makes at
most one coalesced connectOnce attempt before backing off, bounding the
path at 5 sends, 5 reconnects, and ~15s of worst-case backoff.
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The old-engine-new-sdk (Python) job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@igor-kupczynski

igor-kupczynski commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Looking at the test / integration failure:

=== RUN   TestScheduler_PoolIntegration_RemovingTenantStopsSnapshots
    scheduler_integration_test.go:386: unexpected snapshot for removed tenant 6684eb77-ca86-4b99-85aa-007fc3e76933
--- FAIL: TestScheduler_PoolIntegration_RemovingTenantStopsSnapshots (1.56s)
FAIL	github.com/hatchet-dev/hatchet/pkg/scheduling/v1	7.624s
  • The integration job failed on TestScheduler_PoolIntegration_RemovingTenantStopsSnapshots in pkg/scheduling/v1. This is a timing-sensitive test that removes a tenant, sleeps 50ms for async loop cleanup, then asserts no further snapshots arrive; it looks like an in-flight snapshot for the removed tenant raced past the teardown window.
  • this PR touches only pkg/client/* (Go SDK listener/stream refactor), which has no dependency link to the scheduler package.

I'll see if I can get an agent to harden this test in a separate PR.

edit the test passed on a re-run
edit2 flaky test fix: #4385

@igor-kupczynski
igor-kupczynski merged commit 9c2ef1c into main Jul 9, 2026
65 of 66 checks passed
@igor-kupczynski
igor-kupczynski deleted the stream-reconnect-lifecycle branch July 9, 2026 11:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

engine Related to the core Hatchet engine

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants